Skip to content

feat: give the native inserter button platform-appropriate press feedback - #577

Merged
dcalhoun merged 14 commits into
trunkfrom
feat/native-inserter-button-press-feedback
Aug 3, 2026
Merged

feat: give the native inserter button platform-appropriate press feedback#577
dcalhoun merged 14 commits into
trunkfrom
feat/native-inserter-button-press-feedback

Conversation

@dcalhoun

@dcalhoun dcalhoun commented Aug 2, 2026

Copy link
Copy Markdown
Member

What?

Gives the native inserter button press feedback, and replaces its iOS-only styling on Android with Material 3's shape and motion.

Why?

Tapping the button produced no visual confirmation that it registered. Touch devices have no hover state, so the pressed state is the only affordance available.

The button's circular container and its motion were authored for iOS and applied unconditionally, so on Android the shape read as out of place against the platform's shape language.

How?

Split into reviewable commits:

  • Colocate styles — move the button's rules from editor-toolbar/style.scss into a new native-inserter/style.scss. Pure move, no visual change.
  • Press feedback — the icon's background darkens while pressed.
  • Android shape and motion — a rounded square that morphs to full-round while pressed, per Material 3's icon button, using EasingStandard and DurationShort3.
  • iOS motion — the button grows on press, with a linear() easing sampled from SwiftUI's .snappy spring. Small controls grow rather than shrink so their edges stay visible around the finger.
  • Demo default — the Android demo app enables the native inserter by default, matching iOS. The EditorConfiguration default stays false, so host apps are unaffected.

Only static appearance is shared between platforms; each declares its own complete transition list. Non-Android rules are scoped :not(.is-android) rather than is-ios, since neither class is set in a browser during development.

linear() requires WKWebView 17.2 while Package.swift deploys to iOS 17.0, so an eased fallback precedes the @supports block.

Testing Instructions

  1. Run the Android demo app. The native inserter is now enabled by default.
  2. Press and hold the add block (+) button in the editor toolbar.
  3. Confirm the container darkens immediately, grows slightly, and its corners morph from a rounded square to a circle.
  4. Release and confirm it settles back smoothly.
  5. Repeat on the iOS demo app. The button should stay circular, growing and fading in on press, settling with a spring-like curve.

Accessibility Testing Instructions

No change to the button's semantics, accessible name, or focus behavior — these styles are purely visual and apply on :active. The existing "Add block" label and 46px minimum touch target are unchanged.

Screenshots or screencast

Android iOS
android.mp4
ios.mov

dcalhoun and others added 7 commits August 1, 2026 19:11
Move the add block button rules out of the editor toolbar stylesheet and
into a dedicated `native-inserter/style.scss`, matching the colocated
stylesheet convention used by the other components.

Pure move, no visual change. The selector remains scoped to the toolbar
ancestor to preserve specificity over Gutenberg's button defaults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The add block button had no pressed state, so a tap produced no visual
confirmation that it registered. Touch devices have no hover state, making
the pressed state the only available affordance.

Darken the icon's background while pressed. The pressed state applies
instantly and fades back on release, so the feedback does not read as
laggy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The add block button was styled solely for iOS, so its circular container
looked out of place against Android's shape language.

Give Android a rounded square that morphs to full-round while pressed, per
Material 3's icon button. The fill, size, and press color stay shared with
iOS so the button still balances with Gutenberg's toolbar; only the corner
treatment forks.

The fill and the shape need opposite press treatments: the fill darkens
instantly so the tap does not read as laggy, while the morph stays animated
in both directions, since the deformation is the expressive gesture Material
specifies. Setting `transition-duration` alone would flatten both, so the
pressed rules narrow `transition-property` and give each property its own
duration and easing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The button had no iOS-specific press motion, and the shared rule snapped its
fill instantly, which reads harsher than a native control.

Grow the button while pressed and fade the fill in over 0.1s, easing both
back over 0.2s. Small controls grow rather than shrink so their edges stay
visible around the finger; the slower release is what reads as UIKit.

Move motion out of the shared rule so each platform declares its own
complete transition list. The Android pressed rule previously had to
re-declare `transition-property` because the shared rule narrowed it, which
left parallel duration and easing lists to keep index-aligned across
platform blocks. Only static appearance is shared now.

Scope the non-Android rules with `:not(.is-android)` rather than `is-ios`,
since neither platform class is set when the editor runs in a browser during
development.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Android demo app opted out of the native inserter by default, so
verifying it required flipping a toggle on every launch. The iOS demo app
already enables it via `applyDemoAppDefaults`.

Default the toggle on to match. The dependent "Inserter Media Strip" toggle
becomes interactive by default as a result, since it is gated on the native
inserter being enabled.

Only the demo app changes; the `EditorConfiguration` default stays `false`
on both platforms, so host apps are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The button's Android motion used invented values: an easing curve ending at
1.2 that overshot the target radius, and a duration inherited from the iOS
timing variable rather than chosen for Android.

Replace both with the tokens from the `material3` artifact the Android
library builds against — `EasingStandard`, `cubic-bezier(0.2, 0, 0, 1)`, and
`DurationShort3`, 150ms. The standard easing settles flat rather than
overshooting, and Short 3 is the token for a small component's state change.

Apply them to the scale as well as the shape morph, since both are Android
motion and had no reason to differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The button's iOS motion used invented values: an asymmetric 0.1s/0.2s pair
with `ease-out`. SwiftUI animates a state change with a single spring rather
than differing in and out timings, and none of its presets are eased
durations.

Sample `.snappy` — duration 0.5, bounce 0.15, so a damping ratio of 0.85 —
across its settling time into a CSS `linear()` easing, and apply it in both
directions. The overshoot lands well under a device pixel at this size, so
what the approximation buys is the spring's rise shape, which `ease-out`
does not reproduce.

`linear()` needs WKWebView 17.2 while the package deploys to iOS 17.0, so an
eased fallback precedes the `@supports` block. Without it, 17.0 and 17.1
would drop to constant-velocity easing, which reads worse than the curve
being replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the [Type] Enhancement A suggestion for improvement. label Aug 2, 2026
@wpmobilebot

wpmobilebot commented Aug 2, 2026

Copy link
Copy Markdown

XCFramework Build

This PR's XCFramework is available for testing. Add the following to your Package.swift:

.package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/577")

Built from 0e8d318

Enabling the native inserter by default in the demo app broke
`testUndoRedoAfterTyping`. `insertBlock` tapped the "Add block" toggle and
then waited for a web popover, but the toggle now dispatches over the bridge
and presents the native Compose picker, so the web dialog never appeared and
the wait timed out.

Tap the native block tile instead. `BlockTile` is a clickable `Role.Button`,
and `clickable` merges descendants, so the label resolves onto the tile
itself in the merged tree and the matcher pairs `hasClickAction` with
`hasText`. Category tabs are clickable and labelled too, so block names that
collide with a tab name would be ambiguous — noted in a comment, since only
"Paragraph" is used today.

`insertBlock` and `typeInContent` now take the Compose rule, matching the
other rule-based helpers. Drops the web inserter's dialog selector and XPath
builder, which no longer have callers.

Verified against the emulator: both tests in `EditorInteractionTest` pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dcalhoun
dcalhoun force-pushed the feat/native-inserter-button-press-feedback branch from 1d1cea0 to 6b5bdcb Compare August 2, 2026 00:46
dcalhoun and others added 6 commits August 1, 2026 21:08
…ease

The pressed rule set `transition-duration` positionally against the base
rule's property list, so the `0s` only landed on `background-color` by
matching index — reordering the base list would silently retarget it. The
same coupling this file's structure was meant to remove.

It was also one-directional: `0s` applied only while `:active` held, so on
release the fill faded back over 150ms, contradicting Material's immediate
state layer.

Declare the full `transition` shorthand in both rules. The fill now has no
transition in either direction, and neither rule depends on the other's
property order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An unscoped `hasText` match would resolve two nodes for any block name that
is also a category tab ("Text", "Media", "Design"), since tabs are clickable
and labelled too. `waitForNode` wraps `assertExists` in `waitUntilAsserts`,
which swallows the exception — so the ambiguity would surface as a 30s
timeout rather than an "expected 1 node but found 2" failure.

Require a `CollectionInfo` ancestor, which the block grid is the only node
in the sheet to carry, making the collision structurally impossible instead
of merely undocumented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `preventDefault()` on mousedown looks like it would cancel the button's
`:active` styles, which the press feedback depends on. It does not — both
WebViews apply `:active` from the hit test on pointer down, independently of
the default action being cancelled, verified on device.

Note it at the call site so the interaction does not have to be
re-investigated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`waitForNode` polled on `assertExists`, which throws for both "no match yet"
and "more than one match". `waitUntilAsserts` swallows either, so an
ambiguous matcher burned the full timeout and reported "condition still not
satisfied" — hiding the cause behind a 30s hang.

Poll on the match count instead, then assert the count is exactly one. An
ambiguous matcher now fails as soon as its nodes appear, naming the matcher
and the number found. `waitForNodeWithText` routes through the same helper;
each of its callers clicks the node afterwards, which already required a
unique match.

Verified by forcing an ambiguous matcher: fails in 12s with "found 26"
rather than hanging for 30s.

Also corrects the scoping comment. `hasText` compares exactly, so no current
block title collides with a category tab; the grid scoping makes uniqueness
a property of the matcher rather than of the current strings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pressed fill was the one value in this file with no provenance. Every
other value cites its source — `DurationShort3` and `EasingStandard` for the
Android motion, a sampled `.snappy` spring for iOS, a stated rationale for
the shape and scale — but `#d5d3d9` was picked by eye, a flat 7.6% multiply
of each channel from the resting fill.

Derive it instead from Material 3's pressed state layer: `on-surface-variant`
composited over the container. The spec's 10% resolves to #dad9dc, which
under-reads at 32px, where the fill is largely occluded by the finger at the
moment it changes. 16% is the top of the range that still reads as a state
layer rather than a differently colored control, and resolves to #d0cfd3 —
ΔL* 9.4 from the resting fill, up from 7.6.

The same value serves iOS, where a UIKit highlight reads as a low-alpha black
overlay and lands in the same range, so the fill stays shared as before.

Use `color.mix` with an explicit `@use "sass:color"` rather than the global
`mix()`, which is deprecated in Dart Sass and warns on build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
At 32px the previous 1.08 moved each edge by 1.28px — 2.56 device px at 2x,
barely above the threshold where the growth registers at all. The fill is
largely occluded by the finger at the moment it changes, so the edges are
what actually carries the press, and they were doing the least work.

Raise the scale to 1.15, which grows each edge by 2.4px. The painted circle
stays 9.2px inside the toolbar's 46px tap target, so neighboring controls
cannot collide at this or any nearby value.

Chosen from the geometry rather than from device observation; 1.20 is the
next step if this still under-reads, beyond which a control this small starts
to read as springy rather than responsive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dcalhoun
dcalhoun marked this pull request as ready for review August 3, 2026 14:51
@dcalhoun
dcalhoun requested a review from nbradbury August 3, 2026 14:51

@nbradbury nbradbury left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! :shipit:

@dcalhoun
dcalhoun merged commit faa4c80 into trunk Aug 3, 2026
22 checks passed
@dcalhoun
dcalhoun deleted the feat/native-inserter-button-press-feedback branch August 3, 2026 17:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants